- 本文首发于跳跳糖安全社区https://tttang.com/archive/1786/
0x01 Beacon Object File
BOF(Beacon 对象文件)是C/C++编译,但未链接产生的Obj文件,BOF运行在Beacon进程中,并执行内部的Beacon API和Win32 API函数。BOF本质是COFF Obj文件,其符合COFF文件格式规范,结构类似于windows PE文件格式。在被Cobalt Strike加载和使用过程中,BOF是一段地址无关的Shellcode,BOF本身体积比较小,在传输过程中,适用于那些传输带宽小的模式,然后其本身运行在beacon进程内部,不会重新创建进程,也可以有效规避EDR。
0x02 如何开发BOF
下面是官方提供的一个demo
可以使用Visual Studio或者MinGW进行编译,最后生成.obj文件。
在cl.exe生成obj文件的时候,可能遇到fatal error C1034: stdio.h: 不包括路径集
问题,产生这个的原因是没有设置对应的INCLUDE和LIB环境变量。而且不能仅仅设置Vs的Include的路径,还要设置SDK的路径。具体如下:https://blog.csdn.net/weixin_41115751/article/details/89817123
在生成.obj之后,使用inline-execute + obj_path 执行obj文件
BOF内部自带4种API,数据解析API
,主要解析Aggressor Script 使用bof_pack函数打包的参数。打印输出API
,主要起到打印输出的作用。格式化API,以及内部API。内部API主要包含一些令牌句柄的使用,以及进程注入相关的API。具体细节可以参考官方的Bof文档
数据解析API主要包含:
- char BeaconDataExtract (datap parser, int * size)
- int BeaconDataInt (datap * parser)
- int BeaconDataLength (datap * parser)
- void BeaconDataParse (datap parser, char buffer, int size)
- short BeaconDataShort (datap * parser)
在beacon.h中可以看到这些API原型。
0x03 动态函数解析(DFR)
动态函数解析,即Dynamic Function Resolution (DFR)
以下demo的功能是查找当前域,需要使用两个API函数DsGetDcNameA,NetApiBufferFree都是由NETAPI32模块进行导出。
- DECLSPEC_IMPORT:导入函数的关键字
- WINAPI:函数调用约定,一般API函数都是这个
- NETAPI32:函数所在的模块名
- DsGetDcNameA/NetApiBufferFree:函数名称
|
|
可以使用bof_help这个工具自动修改符合BOF格式的函数原型。但目前来说可能不是很好用了。参考自https://idiotc4t.com/weaponization/bof-weaponization
0x04 Obj文件解析
OBj文件的文件类型是COFF Object,使用dumpbin /all obj_path
解析Obj文件格式。Obj文件首先是_IMAGE_FILE_HEADER,保存着整个文件基本信息,然后依次保存着每个节区的SECTION HEADER
和节区内容。然后接着就是重定位表,符号表。
文件头格式如下:
- Machine为0x14c,表示这是一个x86的Obj
- NumberOfSections为4,说明有4个Section
- TimeDateStamp是时间戳
- PointerToSymbolTable;指向符号表
- NumberOfSymbols:符号个数12345678FILE HEADER VALUES14C machine (x86)4 number of sections63*E60*D time date stamp Thu *** 6 13:*:29 20**1E6 file pointer to symbol tableD number of symbols0 size of optional header0 characteristics
节区头的结构体如下:
- Name:表示节区的名字
- SizeOfRawData:表示节区数据的大小
- PointerToRawData:表示节区数据的偏移或者指针。
text段
重定位表
0x05 服务端BOF实现原理
首先是如何定位入口点,如果熟悉Cobalt Strike伪源码的,应该知道Cobalt Strike的命令分发执行位于BeaconConsole.java的public void actionPerformed(ActionEvent var1)
函数,如果不熟悉Cobalt Strike伪源码呢,使用notepad++的文件夹搜索功能,搜索inline-execute
也可以定位到public void actionPerformed(ActionEvent var1)
函数。
显然,可以看见,当执行inline-execute
命令时,首先会将命令中的objectfile的路径解析出来,然后作为参数传入InlineExecuteObject
函数。
在InlineExecuteObject
函数中,首先,调用DataUtils.getBeacon获取Beacon的各种信息,这里使用到的是CPU架构。然后传入this.InlineExecuteObject
函数中。最终调用go()这个函数。
在go这个函数中,依次获取架构,是x64还是x86,并判断Obj的架构和beacon的架构是否一致,一致才可以继续
然后读取Object文件,分别解析Code段,RData段,Data段,和Relocations段,复制这些段的数据,复制原理如下,首先,通过解析Header中的数据,可以获取各个段的起始地址和大小,这样就可以获取各个段的范围,然后就可以获取指定段的内容。
在getRelocations()
函数中,会根据不同的段,插入不同的Magic Number。例如,如果是.rdata,则会插入1024,如果是.data,则会插入1025,如果是.text,则会插入1026,如果是DynamicFunction,则会插入1027,最后以插入1028结尾。
同时,可以看到插入数据的结构,首先是插入的Type(类型),然后插入一个Magic Number,第三是插入偏移,最后插入在段中的偏移。有个例外,针对DynamicFunction这块的处理可能需要插入其他的数据。
接着是构造命令,依次添加命令号,添加obj的入口点,添加code,添加Rdata,添加data,添加Relocations,和Arguments,这个Arguments没理解是什么东西。
0x06 beacon端调用原理分析
beacon分为loader和payload,loader可以自行开发,payload采用反射注入的方式进行加载,默认情况下,导出表有两个函数,一个是ReflectiveLoader
另外一个是DllEntryPoint
。在执行payload的时候,优先执行ReflectiveLoader,在处理完PE数据后,跳转到DllEntryPoint
函数,然后根据dll加载的原因选择进行数据的初始化,还是进行工作。
在4.1的Cobalt Strike生成的beacon中,大概在这个地方(Sub_336560_CommandDisPatch)进行命令操作。
这是服务端传来的原始数据,显然,前四个字节正好是100,为命令号,和cobaltstrike发送命令数据的结构一致。
在函数Sub_336560_CommandDisPatch
中,显然可以看到,首先解析出命令号,然后将除了命令号以外的数据作为第二个参数传入,将结果作为第三个参数传入,用以获取执行的结果。
根据命令号,选择不同需要执行的函数,此处将该函数命名为Sub_32D020_inline_execute
,在Sub_32D020_inline_execute
中,首先依次解析Code段,RData段,Data段,Relocations段,和Arguments。
随后,便开始解析Relocations段,cobaltstrike通过不同的硬编码数据将不同的数据类型进行分割,0x400表示.rdata段,0x401表示.data段,0x402表示.text段,0x403表示DynamicFunction,0x404则表示结束。
Sub_32D4F4_WriteOffset
函数的目的是修改代码段中的一些常量或者DynamicFunction的地址,因为在汇编层级,这些地址都是偏移量,所以需要计算偏移量并写入代码中,才能实现调用。
最后执行shellcode
以下是传入的数据。显然,第一行是命令号和EntryPoint,然后下面是code段和rdata段,再下面是Relocations段。
然后在对比一下原始的code段,和需要执行的shellcode的区别,很显然,关于常量的偏移地址是不同的。也就是说此处做了重定位。
0x07 检测思路
常规的工具(BeaconEye)可能没有什么好的检测思路,我之前设想过,通过beaconEye有没有可能检测BOF,但是后来仔细想了一下发现不行,因为BeaconEye通过检测内存中的特征码实现的,但是BOF在调用执行完shellcode的时候就被释放了,可能无法检测。
从流量角度看,传入的流量数据,起始的命令号(100)以及getRelocations中的Magic Number是否可以作为检测依据?
0x08 execuate-assembly
在CobaltStrike3中,新增了名为execuate-assembly
命令,该命令本质是实现了在内存中加载.Net程序集。
执行execute-assembly
命令之后,判断是否存在参数,如果存在参数,得到CSharp程序路径和参数,分别传入ExecuteAssembly,如果不存在参数,只需要传入CSharp程序路径。
在ExecuteAssembly
函数中,首先读取Charp程序,并判断其是否是一个.NET程序,然后根据Beacon判断是否是64位系统,如果是X64的话,则会加载X64的装载程序初始化CRL环境并加载.NET程序
读取resources/invokeassembly.dll
文件,该程序的作用是初始化CLR以及加载.Net程序集,然后将invokeassembly.dll
,CSharp程序
,以及一些配置信息一起发送给beacon。
beacon.exe,第70号命令即为execte-assembly
,其逻辑也很简单,在解析玩配置信息之后,拉起一个Rundll32进程,然后将invokeassembly.dll注入进去,invokeassembly.dll就会在rundll32中初始化环境并加载程序集了。
转储了invokeassembly.dll,拖到IDA中,发现其和beacon一样采用了反射注入的方式。直接定位到关键函数。
首先初始化CLR环境,根据不同的版本采用不同的函数初始化CLR环境,
启动CLR环境
获取默认的程序域
加载assembly
获取入口点,并执行。
idiotc4t在Execute-Assembly实现中仔细描述了如何编写一段内存加载.Net程序集。首先初始化CLR环境,CLR全称为公共语言运行库,即Common Language Runtime。CLR托管在进程中,是加载和运行.Net程序集的地方,关于CLR的概述可以参考微软关于CLR的描述。常见的windows进程并不会加载CLR环境,可以使用ProcessExplorer或者ProcessHacker等工具查看是否加载CLR环境。加载CLR环境主要分四步:
- 1) 调用CLRCreateInstance函数以实例化
ICLRMetaHost
或ICLRMetaHostPolicy
接口,CLRCreateInstance函数原型如下,第一个参数为clsid,第二个参数是 riid,第三个参数是返回的接口。
|
|
2) 调用
ICLRMetaHost::EnumerateInstalledRuntimes
,ICLRMetaHost::GetRuntime
或者ICLRMetaHostPolicy::GetRequestedRuntime
方法以获取有效的ICLRRuntimeInfo指针。以ICLRMetaHost::GetRuntime
为例,第一个参数为pwzVersion,表示 .NET Framework 的版本,riid为标识符,此参数的唯一有效值是 IID_ICLRRuntimeInfo。第三个参数是返回的ICLRRuntimeInfo接口的指针。12345HRESULT GetRuntime ([in] LPCWSTR pwzVersion,[in] REFIID riid,[out,iid_is(riid), retval] LPVOID *ppRuntime);3) 调用
GetInterface
获取ICorRuntimeHost
或者ICLRRuntimeHost
。rclsid为接口的CLSID,riid是接口的iid,ppUnk返回的接口的指针。加载.Net程序集可以使用两种接口ICorRuntimeHost
或者ICLRRuntimeHost
。使用ICorRuntimeHost
的有点是可以兼容V1.0的程序集,但是ICLRRuntimeHost
在代码实现上会比较容易。1234HRESULT GetInterface([in] REFCLSID rclsid,[in] REFIID riid,[out, iid_is(riid), retval] LPVOID *ppUnk);4)启动CLR环境集
1234CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (VOID**)&iMetaHost);iMetaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (VOID**)&iRuntimeInfo);iRuntimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (VOID**)&iRuntimeHost);iRuntimeHost->Start();
程序域为安全性、可靠性和版本控制以及卸载程序集提供了隔离边界,需要将程序集加载到对应的程序域中,才能执行其中包含的代码,这也就是为啥要获取程序集的原因了。程序集的加载方式决定了它的即时 (JIT) 编译代码是否可以由进程中的多个应用程序域共享,以及程序集是否可以从进程中卸载。具体关于程序域和程序集可以参考微软关于程序域和程序集的概述,通过调用GetDefaultDomain
获取默认的程序集,并通过调用QueryInterface
检索该程序集的接口。
在拥有运行时环境CLR,已经可以被托管的容器程序域之后,可以加载程序集了。调用Load_3
函数加载程序集安全数组,并获取入口点。
最后,调用Invoke_3执行程序集的入口点。